// https://github.com/patrikx3/angular-compile

import { CommonModule } from '@angular/common';
import { parseTemplate } from '@angular/compiler';
import {
  AfterViewInit,
  Compiler,
  Component,
  EventEmitter,
  Injectable,
  Input,
  ModuleWithProviders,
  NgModule,
  NgModuleFactory,
  OnChanges,
  Output,
  SimpleChanges,
  Type,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SampleCode } from 'app/shared/model';
import { cloneDeep } from 'lodash';
import * as ts from 'typescript';

const parseCode = (code: string) => {
  const props = {};
  const imports = {};
  const methods = {};
  // TODO: get error from parseDiagnostics
  const compileErrors: string[] = [];
  const sourceFile = ts.createSourceFile('', code, ts.ScriptTarget.Latest);
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

  sourceFile.forEachChild((child) => {
    if (ts.isClassDeclaration(child)) {
      child.members.forEach((member) => {
        if (ts.isPropertyDeclaration(member)) {
          try {
            const name = member.name.getText(sourceFile);
            const initializer = member['initializer'].getText(sourceFile);
            const jsIniitalizer = ts.transpileModule(initializer, { compilerOptions: { module: ts.ModuleKind.ES2020 }}).outputText;
            const value = eval(jsIniitalizer);
            props[name] = value;
          } catch (error) {
            compileErrors.push(error.toString());
          }
        }

        if (ts.isConstructorDeclaration(member)) {
          try {
            const paremeters = (member as ts.ConstructorDeclaration).parameters;
            paremeters.forEach((param) => {
              const name = (param.name as ts.Identifier).escapedText as string;
              const type = ((param.type as ts.TypeReferenceNode).typeName as ts.Identifier).escapedText;
              imports[name] = type;
            });
          } catch (error) {
            compileErrors.push(error.toString());
          }
        }

        if (ts.isMethodDeclaration(member)) {
          try {
            const name = member.name.getText(sourceFile);
            const params = member.parameters.map((parameter) => (parameter.name as ts.Identifier).escapedText);
            const content = printer.printNode(ts.EmitHint.Unspecified, (member as ts.MethodDeclaration).body, sourceFile).slice(1, -1);
            const jsContent = ts.transpileModule(content, { compilerOptions: { module: ts.ModuleKind.ES2020 }}).outputText;
            methods[name] = [...params, jsContent];
          } catch (error) {
            compileErrors.push(error.toString());
          }
        }
      });
    }
  });

  return {
    props,
    imports,
    methods,
    compileErrors,
    success: compileErrors.length === 0,
  };
};

@Component({
  selector: 'fui-compiler',
  styleUrls: ['./compiler.component.sass'],
  template: `
    <ng-container *ngIf="code.HTML">
      <ng-container
        *ngComponentOutlet="dynamicComponent; ngModuleFactory: dynamicModule"
      ></ng-container>
    </ng-container>
  `,
})
export class CompilerComponent implements OnChanges {
  @Input() code: SampleCode;

  @Input() context: any;

  @Input() module: NgModule;

  @Input() imports: Array<Type<any> | ModuleWithProviders<any> | any[]>;

  @Output() compileError = new EventEmitter<Error>();

  compileErrors: string[] = [];

  dynamicComponent: any;

  dynamicModule: NgModuleFactory<any> | any;

  interpolationConfig = {
    end: '}}',
    start: '{{',
  };

  constructor(
    private compiler: Compiler,
  ) {}

  ngOnChanges(_changes: SimpleChanges) {
    this.updateComponent();
  }

  updateComponent() {
    this.code.trimCode();

    if (this.code.empty('HTML')) {
      this.resetModule();
      return;
    }

    const errors = this.validateCode();
    const valid = errors.length === 0;
    const {props, methods, imports} = parseCode(this.code.TS);

    try {
      this.dynamicComponent = this.createNewComponent(
        valid,
        valid ? this.code.HTML : errors.join('\n\n'),
        this.code.CSS,
        props,
        imports,
        methods,
        this.context,
      );
      this.dynamicModule = this.compiler.compileModuleSync(
        this.createComponentModule(this.dynamicComponent),
      );
    } catch (e) {
      this.compileError.emit(e);
    }
  }

  resetModule() {
    this.dynamicComponent = undefined;
    this.dynamicModule = undefined;
  }

  validateCode(): string[] {
    const errors: string[] = [...this.validateTemplate(), ...this.validateTs()];

    return errors;
  }

  validateTemplate(): string[] {
    let errors: string[] = [];
    const template = parseTemplate(this.code.HTML, '', {
      preserveWhitespaces: false,
      interpolationConfig: this.interpolationConfig,
    });

    if (template.errors !== null) {
      errors.push('Errors during JIT compilation of template for DynamicComponent:');
      errors = errors.concat(template.errors.map((err) => err.toString()));
    }

    return errors;
  }

  validateTs() {
    const { success, compileErrors } = parseCode(this.code.TS);
    let errors: string[] = [];

    if (!success) {
      errors.push('Errors during compilation of ts code:');
      errors = errors.concat(compileErrors);
    }

    return errors;
  }

  completeModuleMeta(componentType: any): NgModule {
    let module: NgModule = {};

    if (this.module !== undefined) {
      module = cloneDeep(this.module);
    }

    module.imports = module.imports || [];
    module.imports = module.imports.concat(this.imports || []);
    module.imports.push(CommonModule);
    module.imports.push(FormsModule);

    module.declarations =
    module.declarations === undefined
      ? [componentType]
      : module.declarations.concat([componentType]);

    // tslint:disable-next-line: deprecation
    module.entryComponents = [componentType];

    return module;
  }

  private createComponentModule(componentType: any) {
    const module = this.completeModuleMeta(componentType);

    @NgModule(module)
    class RuntimeComponentModule {}

    return RuntimeComponentModule;
  }

  private createNewComponent(
    valid = true,
    html: string,
    css: string,
    props: Record<string, unknown> = {},
    imports: Record<string, unknown> = {},
    methods: Record<string, unknown> = {},
    context: any,
  ) {
    const id = this.nextId();
    const errorHtml = `<div class="compile-error" id="${id}"></div>`;
    const template = valid ? html : errorHtml;

    @Component({
      selector: id,
      template,
      styles: [css],
    })
    class DynamicComponent implements AfterViewInit {
      context: any = context;

      constructor() {
        for (const key in props) {
          if (Object.prototype.hasOwnProperty.call(props, key)) {
            const prop = props[key];
            this[key] = prop;
          }
        }

        if (this.context) {
          for (const key in imports) {
            if (Object.prototype.hasOwnProperty.call(imports, key)) {
              const importInstance = this.context[key];
              if (importInstance) {
                this[key] = importInstance;
              }
            }
          }
        }

        for (const key in methods) {
          if (Object.prototype.hasOwnProperty.call(methods, key)) {
            const methodArgs = methods[key];
            this[key] = Function.apply(this, methodArgs);
          }
        }
      }

      ngAfterViewInit() {
        const el = document.getElementById(id);
        if (!valid && el) {
          el.innerText = html;
        }
      }
    }

    return DynamicComponent;
  }

  private nextId() {
    const randomString = Math.random().toString(36).substring(2);
    return `fui-angular-compile-${randomString}`;
  }
}
